iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Software Development

Android 鐵人賽: 天命最高 - 陪伴大家一步步打造屬於自己的app系列 第 23

GridView 自訂 Adapter:用 ViewHolder 打造圖片選擇器

  • 分享至 

  • xImage
  •  

DAY 24|GridView 自訂 Adapter:用 ViewHolder 打造圖片選擇器

之前有使用過 ListView 顯示直向清單,今天進一步改用 GridView,把圖片與名稱排列成網格。使用者點選其中一個項目後,畫面下方的 ImageView 便會立即顯示被選取的大圖。本篇也會實作自訂 BaseAdapter,並利用 ViewHolderconvertView 改善捲動效能。
參考資料:https://developer.android.com/reference/android/widget/GridView


一、今天要完成的功能

今天的 App 包含以下功能:

  1. strings.xml 讀取名稱陣列。
  2. 從資源陣列讀取圖片。
  3. 將名稱與圖片組合成資料清單。
  4. 使用自訂 MyAdapter 顯示每個網格項目。
  5. 使用 ViewHolder 快取元件。
  6. 點選 GridView 項目後,更新下方的大圖。
  7. 使用 TypedArray.recycle() 釋放資源。

GridView 是一種二維、可捲動的網格元件,每一格的畫面內容由 Adapter 提供,因此非常適合製作相簿、商品列表、功能選單或縣市小吃圖片牆。
參考資料:https://developer.android.com/reference/android/widget/GridView


二、程式架構

今天的資料顯示流程如下:

flowchart TD
    A["字串與圖片資源陣列"] --> B["建立 listData"]
    B --> C["建立 MyAdapter"]
    C --> D["綁定 GridView"]
    D --> E["MyAdapter.getView()"]
    E --> F["顯示圖片與名稱"]
    F --> G["使用者點選項目"]
    G --> H["更新下方 ImageView 大圖"]

Adapter 的主要工作是充當「資料」與「畫面元件」之間的橋梁。GridView 不需要知道資料從哪裡來,只會向 Adapter 詢問資料數量、指定位置的資料,以及該位置應該顯示的 View。
參考資料:https://developer.android.com/reference/android/widget/Adapter


https://ithelp.ithome.com.tw/upload/images/20260823/20112100KP33OAuAYy.png

三、資料格式設計

這次使用 MutableList<MutableMap<String, Any>> 保存網格資料,每一筆資料包含兩個欄位:

Key 資料內容 資料型別
name 圖片名稱 String
pic Drawable 圖片資源 ID Int

程式中的一筆資料概念如下:

val data = mutableMapOf<String, Any>()
data["name"] = "第一張圖片"
data["pic"] = R.drawable.flower1

因為同一個 Map 中同時存放 StringInt,所以 Value 型別宣告為 Any。取出資料時,必須再轉換成正確型別,例如 item["pic"] as Int
參考資料:https://kotlinlang.org/docs/collections-overview.html


四、MainActivity.kt 完整程式

MainActivity 負責取得畫面元件、載入名稱與圖片資源、建立資料清單、設定 Adapter,以及處理項目點擊事件。
參考資料:https://developer.android.com/reference/android/app/Activity

// 原始範例參考:
// https://chatgpt.com/s/m_6a840fa4d51881918ba37cc3810692e5

package com.example.listview6

import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.AdapterView
import android.widget.GridView
import android.widget.ImageView
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 啟用 Edge-to-Edge,讓 App 畫面可以延伸至系統狀態列與導覽列區域
        enableEdgeToEdge()

        // 載入主畫面 XML
        setContentView(R.layout.activity_main)

        // 取得系統狀態列與導覽列的 Insets,
        // 並設定為主畫面的 Padding,避免內容被系統列遮住
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { view, insets ->
            val systemBars =
                insets.getInsets(WindowInsetsCompat.Type.systemBars())

            view.setPadding(
                systemBars.left,
                systemBars.top,
                systemBars.right,
                systemBars.bottom
            )

            insets
        }

        // Teacher Version

        // 取得畫面中的 GridView
        val gridViewData =
            findViewById<GridView>(R.id.gridView_id)

        // 取得用來顯示選取結果的大圖 ImageView
        val imageViewPic =
            findViewById<ImageView>(R.id.ImageView_pic)

        // 從 strings.xml 取得名稱字串陣列
        val nameArray =
            resources.getStringArray(R.array.name)

        // 從資源陣列取得圖片資源
        val picArray =
            resources.obtainTypedArray(R.array.picture)

        // 建立 GridView 使用的資料清單
        // 每一筆 MutableMap 都會保存 name 與 pic
        val listData =
            mutableListOf<MutableMap<String, Any>>()

        // 依照名稱陣列的數量逐筆建立資料
        for (i in 0..nameArray.size - 1) {

            // 建立一筆可修改的 Map
            val data =
                mutableMapOf<String, Any>()

            // 放入圖片名稱
            data["name"] = nameArray[i]

            // 從圖片資源陣列取得指定位置的 Drawable 資源 ID
            // 如果該位置無法取得圖片,就使用 flower1 作為預設圖片
            data["pic"] =
                picArray.getResourceId(
                    i,
                    R.drawable.flower1
                )

            // 將這筆資料加入總清單
            listData.add(data)
        }

        // TypedArray 使用完畢後必須回收,
        // 讓系統可以重新使用其內部資源
        picArray.recycle()

        // 在 Logcat 輸出建立完成的資料,方便除錯
        Log.d(
            "main",
            "list data = $listData"
        )

        /**
         * 建立自訂 MyAdapter。
         *
         * this@MainActivity:
         * 提供 Adapter 所需的 Context,
         * 讓 Adapter 可以取得版面配置與其他資源。
         *
         * listData:
         * GridView 的資料來源,
         * 每一筆資料都包含圖片名稱與圖片資源 ID。
         */
        val adapter = MyAdapter(
            myContext = this@MainActivity,
            listData = listData
        )

        // 將自訂 Adapter 指定給 GridView
        gridViewData.adapter = adapter

        // 設定 GridView 項目的點擊監聽器
        gridViewData.onItemClickListener =
            object : AdapterView.OnItemClickListener {

                override fun onItemClick(
                    parent: AdapterView<*>?,
                    view: View?,
                    position: Int,
                    id: Long
                ) {
                    // 根據被點選的位置,
                    // 從 Adapter 取得該格對應的資料
                    val item =
                        parent?.getItemAtPosition(position)
                            as MutableMap<String, Any>

                    // 取出圖片資源 ID,
                    // 並更新下方的大圖 ImageView
                    imageViewPic.setImageResource(
                        item["pic"] as Int
                    )
                }
            }
    }
}

resources.getStringArray() 可以讀取 <string-array>resources.obtainTypedArray() 則可以讀取包含 Drawable 資源的陣列。由於 TypedArray 是共用的系統資源,使用完畢後應呼叫 recycle()
參考資料:https://developer.android.com/reference/android/content/res/Resources


五、建立名稱與圖片陣列

名稱陣列通常可以放在:

app/src/main/res/values/strings.xml

範例:

<resources>

    <string name="app_name">GridViewDemo</string>

    <!-- GridView 顯示的名稱陣列 -->
    <string-array name="name">
        <item>花朵 1</item>
        <item>花朵 2</item>
        <item>花朵 3</item>
        <item>花朵 4</item>
        <item>花朵 5</item>
        <item>花朵 6</item>
    </string-array>

</resources>

將文字放入字串資源,可以讓畫面文字與程式邏輯分離,也方便日後製作繁體中文、英文或日文等多語系版本。
參考資料:https://developer.android.com/guide/topics/resources/string-resource

圖片陣列可以建立在:

app/src/main/res/values/arrays.xml

完整範例:

<?xml version="1.0" encoding="utf-8"?>

<resources>

    <!-- GridView 使用的圖片資源陣列 -->
    <array name="picture">
        <item>@drawable/flower1</item>
        <item>@drawable/flower2</item>
        <item>@drawable/flower3</item>
        <item>@drawable/flower4</item>
        <item>@drawable/flower5</item>
        <item>@drawable/flower6</item>
    </array>

</resources>

名稱陣列與圖片陣列的位置必須互相對應,例如 name[0] 對應 picture[0]。如果兩個陣列的數量不一致,可能取得預設圖片,或因索引位置超出範圍而發生錯誤。
參考資料:https://developer.android.com/guide/topics/resources/more-resources#TypedArray


六、為什麼需要自訂 Adapter?

Android 已經提供 ArrayAdapterSimpleAdapter 等現成配接器,但今天的每一格同時包含一張圖片與一段文字,而且還要控制 View 的重用,因此自行繼承 BaseAdapter 會有比較高的自由度。
參考資料:https://developer.android.com/reference/android/widget/BaseAdapter

繼承 BaseAdapter 後,主要必須實作以下四個方法:

方法 功能
getCount() 回傳資料總筆數
getItem(position) 回傳指定位置的資料
getItemId(position) 回傳指定位置的 ID
getView(position, convertView, parent) 建立或重用每個項目的 View

其中最重要的是 getView()。每當 GridView 準備顯示某一格時,就會呼叫它取得該位置所需要的 View。
參考資料:https://developer.android.com/reference/android/widget/Adapter#getView(int,%20android.view.View,%20android.view.ViewGroup)


七、MyAdapter.kt 完整程式

以下是今天使用的完整自訂 Adapter,程式中已保留並加強 ViewHolder 與 View 重用機制的註解。
參考資料:https://developer.android.com/reference/android/widget/BaseAdapter

package com.example.listview6

import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ImageView
import android.widget.TextView

/**
 * 自訂配接器 MyAdapter。
 *
 * 功能:
 * 將 listData 中的名稱與圖片資料,
 * 綁定到 item_layout.xml 的 ImageView 與 TextView。
 */
class MyAdapter(
    private val myContext: Context,
    private val listData: MutableList<MutableMap<String, Any>>
) : BaseAdapter() {

    /**
     * 回傳資料總筆數。
     *
     * GridView 會依照這個數字,
     * 判斷總共有多少個項目需要顯示。
     */
    override fun getCount(): Int {
        return listData.size
    }

    /**
     * 取得指定位置的資料物件。
     *
     * position:
     * 使用者點選或 GridView 要求顯示的資料位置。
     */
    override fun getItem(position: Int): Any? {
        return listData[position]
    }

    /**
     * 取得指定位置的項目 ID。
     *
     * 目前沒有資料庫的固定 ID,
     * 所以直接將 position 轉換為 Long。
     */
    override fun getItemId(position: Int): Long {
        return position.toLong()
    }

    /**
     * 建立或重用 GridView 中的單一項目 View。
     *
     * position:
     * 目前要顯示的資料位置。
     *
     * convertView:
     * 系統提供的舊 View。
     * 如果不為 null,代表可以直接重複利用。
     *
     * parent:
     * 這個項目最後會加入的父容器,也就是 GridView。
     */
    override fun getView(
        position: Int,
        convertView: View?,
        parent: ViewGroup?
    ): View {

        // 最後要回傳給 GridView 的項目畫面
        val view: View

        // 保存 ImageView 與 TextView 參照的 ViewHolder
        val holder: ViewHolder

        if (convertView == null) {

            // convertView 為 null:
            // 目前沒有可以重複使用的項目畫面,
            // 因此需要載入新的 item_layout.xml
            view = LayoutInflater
                .from(myContext)
                .inflate(
                    R.layout.item_layout,
                    parent,
                    false
                )

            // 只在建立新 View 時呼叫 findViewById
            holder = ViewHolder(
                itemImageView =
                    view.findViewById(
                        R.id.imageView_itemPic
                    ),
                itemTextView =
                    view.findViewById(
                        R.id.textView_itemName
                    )
            )

            // 將 ViewHolder 保存到 View 的 tag 中
            // 下次重用這個 View 時,就不需要再次 findViewById
            view.tag = holder

        } else {

            // convertView 不為 null:
            // 表示系統提供了一個離開畫面的舊 View,
            // 可以直接拿來顯示新的資料
            view = convertView

            // 從 tag 取回之前保存的 ViewHolder
            holder = view.tag as ViewHolder
        }

        // 取得目前 position 對應的資料
        val item = listData[position]

        // 將名稱設定到 TextView
        holder.itemTextView.text =
            item["name"].toString()

        // 將圖片資源 ID 設定到 ImageView
        holder.itemImageView.setImageResource(
            item["pic"] as Int
        )

        // 將完成資料綁定的 View 回傳給 GridView
        return view
    }

    /**
     * ViewHolder 用來保存 item_layout 中的元件參照。
     *
     * 這樣可以避免 GridView 捲動時,
     * 不斷重複呼叫 findViewById。
     */
    private class ViewHolder(
        val itemImageView: ImageView,
        val itemTextView: TextView
    )
}

LayoutInflater.inflate() 會把 item_layout.xml 轉換為真正的 View 物件。第三個參數設定為 false,代表目前先不要直接加入父容器,後續會由 GridView 自行管理。
參考資料:https://developer.android.com/reference/android/view/LayoutInflater


八、ViewHolder 到底是什麼?

ViewHolder 的主要目的,是保存每一格中 ImageViewTextView 的參照。第一次建立項目時執行 findViewById(),之後把 ViewHolder 放進 view.tag;當 View 被重用時,再從 tag 把它取出來。
參考資料:https://developer.android.com/reference/android/view/View#setTag(java.lang.Object)

核心程式如下:

if (convertView == null) {
    view = LayoutInflater
        .from(myContext)
        .inflate(R.layout.item_layout, parent, false)

    holder = ViewHolder(
        itemImageView =
            view.findViewById(R.id.imageView_itemPic),
        itemTextView =
            view.findViewById(R.id.textView_itemName)
    )

    view.tag = holder
} else {
    view = convertView
    holder = view.tag as ViewHolder
}

如果沒有使用 ViewHolder,getView() 每次被呼叫時都必須重新執行 findViewById()。資料不多時可能感覺不明顯,但大量圖片快速捲動時,重複尋找元件會增加處理負擔。
參考資料:https://developer.android.com/reference/android/view/View#findViewById(int)


九、convertView 的回收原理

假設畫面一次只能顯示六個項目,即使資料有一百筆,Android 也不需要一次建立一百個 View。當最上方項目滑出螢幕後,它的 View 可以透過 convertView 交給後面即將進入畫面的項目重複使用。
參考資料:https://developer.android.com/reference/android/widget/Adapter#getView(int,%20android.view.View,%20android.view.ViewGroup)

流程可以整理成:

flowchart TD
    A["呼叫 getView()"] --> B{"convertView 是否為 null?"}
    B -- "是" --> C["載入 item_layout"]
    C --> D["建立 ViewHolder"]
    D --> E["保存到 view.tag"]
    B -- "否" --> F["重用 convertView"]
    F --> G["從 view.tag 取回 ViewHolder"]
    E --> H["綁定目前 position 的資料"]
    G --> H
    H --> I["回傳 View"]

這種設計可以減少 View 建立次數,也可以減少 findViewById() 的執行次數,讓傳統 ListViewGridView 的捲動更加順暢。
參考資料:https://developer.android.com/reference/android/view/View#getTag()


十、item_layout.xml 範例

每一個網格項目需要一個版面配置,檔案可以建立在:

app/src/main/res/layout/item_layout.xml

完整範例:

<?xml version="1.0" encoding="utf-8"?>

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:gravity="center"
    android:padding="8dp">

    <!-- 顯示每一格的縮圖 -->
    <ImageView
        android:id="@+id/imageView_itemPic"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:contentDescription="@string/app_name"
        android:scaleType="centerCrop"
        android:src="@drawable/flower1" />

    <!-- 顯示圖片名稱 -->
    <TextView
        android:id="@+id/textView_itemName"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="6dp"
        android:gravity="center"
        android:text="圖片名稱"
        android:textColor="#212121"
        android:textSize="16sp" />

</LinearLayout>

ImageViewscaleType="centerCrop" 會維持圖片比例並填滿顯示範圍,超出的部分會被裁切;如果希望完整顯示圖片,可以改成 fitCenter
參考資料:https://developer.android.com/reference/android/widget/ImageView.ScaleType


十一、activity_main.xml 範例

主畫面上方放置 GridView,下方放置顯示選取結果的 ImageView
參考資料:https://developer.android.com/develop/ui/views/layout/declaring-layout

<?xml version="1.0" encoding="utf-8"?>

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">

    <!-- 顯示圖片與名稱的網格 -->
    <GridView
        android:id="@+id/gridView_id"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:columnWidth="120dp"
        android:gravity="center"
        android:horizontalSpacing="8dp"
        android:numColumns="auto_fit"
        android:stretchMode="columnWidth"
        android:verticalSpacing="8dp" />

    <!-- 顯示目前選取的大圖 -->
    <ImageView
        android:id="@+id/ImageView_pic"
        android:layout_width="match_parent"
        android:layout_height="220dp"
        android:layout_marginTop="12dp"
        android:contentDescription="@string/app_name"
        android:scaleType="fitCenter"
        android:src="@drawable/flower1" />

</LinearLayout>

android:numColumns="auto_fit" 會依照畫面寬度及 columnWidth 自動計算欄數,因此在不同尺寸的手機或平板上,網格可以自行調整。
參考資料:https://developer.android.com/reference/android/widget/GridView#attr_android:numColumns


十二、處理 GridView 點擊事件

GridView 的單一項目點擊事件可透過 onItemClickListener 處理:

gridViewData.onItemClickListener =
    object : AdapterView.OnItemClickListener {

        override fun onItemClick(
            parent: AdapterView<*>?,
            view: View?,
            position: Int,
            id: Long
        ) {
            val item =
                parent?.getItemAtPosition(position)
                    as MutableMap<String, Any>

            imageViewPic.setImageResource(
                item["pic"] as Int
            )
        }
    }

position 是使用者點選的資料位置,從零開始計算。程式透過 getItemAtPosition(position) 取得對應資料,再將 pic 轉換成圖片資源 ID,最後交給 setImageResource() 更新大圖。
參考資料:https://developer.android.com/reference/android/widget/AdapterView.OnItemClickListener


十三、TypedArray 為什麼要 recycle?

以下程式會取得圖片資源陣列:

val picArray =
    resources.obtainTypedArray(R.array.picture)

使用完成後呼叫:

picArray.recycle()

TypedArray 內部會使用由系統維護的共用資源。呼叫 recycle() 後,該物件便可以重新被系統利用;回收後不能繼續讀取裡面的內容,因此必須先把圖片資源 ID 存入 listData,最後才能回收。
參考資料:https://developer.android.com/reference/android/content/res/TypedArray#recycle()


十四、這份程式可以再改進的地方

目前使用 MutableMap<String, Any> 儲存資料,雖然彈性很高,但取出圖片時需要手動執行 as Int。如果 Key 寫錯或資料型別不正確,就可能在執行階段發生 ClassCastException
參考資料:https://kotlinlang.org/docs/typecasts.html

較安全的方式是建立資料類別:

/**
 * GridView 每一格所使用的資料模型。
 */
data class GridItem(
    val name: String,
    val pictureResId: Int
)

資料清單便可以改成:

val listData = mutableListOf<GridItem>()

for (i in nameArray.indices) {
    listData.add(
        GridItem(
            name = nameArray[i],
            pictureResId = picArray.getResourceId(
                i,
                R.drawable.flower1
            )
        )
    )
}

使用 data class 後,資料欄位具有明確型別,不需要依靠 "name""pic" 等字串 Key,也不必頻繁執行型別轉換,程式會更安全且更容易維護。
參考資料:https://kotlinlang.org/docs/data-classes.html


十五、今日重點整理

今天從一般清單前進到圖文網格,完成了 GridView 與自訂 BaseAdapter 的整合。真正重要的並不只是把圖片顯示出來,而是理解 Adapter 如何根據 position 提供資料,以及 convertView 如何回收離開螢幕的 View。
參考資料:https://developer.android.com/reference/android/widget/AdapterView

本日核心觀念如下:

  • GridView 負責排列網格。
  • BaseAdapter 負責提供資料與每格畫面。
  • getView() 負責建立或重用項目 View。
  • convertView 是可以重複使用的舊 View。
  • ViewHolder 快取元件參照。
  • view.tag 保存並取回 ViewHolder。
  • onItemClickListener 處理格子點擊。
  • TypedArray.recycle() 回收圖片陣列資源。

這套概念不只適用於圖片選擇器,也可以延伸成商品列表、相簿、餐點選單、縣市小吃圖鑑與功能入口選單。雖然新專案通常會優先採用 RecyclerView 或 Jetpack Compose,但理解 GridView 的 Adapter 與 View 回收機制,仍然有助於建立 Android 清單元件的基礎觀念。
參考資料:https://developer.android.com/develop/ui/views/layout/recyclerview


今日結語

透過今天的實作,我們已經將「資料陣列 → 自訂 Adapter → GridView → 使用者點擊 → 更新大圖」串成完整互動流程,也進一步認識 ViewHolderconvertView 的效能優化方式。下一步可以將資料改為型別安全的 data class,再進一步升級成 RecyclerView + GridLayoutManager
參考資料:https://developer.android.com/develop/ui/views/layout/recyclerview#customizing

完整官方參考資料:


上一篇
Android 從 ListView 到感測器、ViewHolder、Spinner 與 SQLite:讓 App 從「顯示資料」進化到「感知與儲存」
下一篇
別再用 Map 硬塞資料:用 Kotlin Data Class 重構 GridView
系列文
Android 鐵人賽: 天命最高 - 陪伴大家一步步打造屬於自己的app30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言